4편. Vue render 과정 이해
VNode 모델 → h() → createApp/render/patch → 엘리먼트/컴포넌트 마운트/업데이트 → props/style/event/children diff → 인보커(Invoker) 전략까지 흐름으로 설명한다. 핵심 메시지는 간단하다: 렌더러는 반응성 엔진(effect/track/trigger) 위에서 "상태를 읽는 render 함수"를 실행하며, 그 결과를 DOM으로 투영한다.
0. 렌더러의 역할
- 입력: 컴포넌트의
render(ctx)가 반환하는 VNode 트리 - 작업: 이전 VNode 트리와 현재 트리를 비교(patch)하여 DOM 변경 최소화
- 출력: DOM 업데이트
렌더러 자신은 상태 관리하지 않는다. 상태는 반응성 엔진이 관리하고, 상태가 바뀌면 trigger → effect(updateComponent)가 실행되어 렌더러가 다시 일한다.
1. VNode 모델과 h()
ts
export type VNode = {
type: string | Component<any, any>;
props: Record<string, any> | null;
children: string | VNode[] | null;
key?: any;
el: Node | null; // 실제 DOM 참조(마운트 후)
component: ComponentInstance | null;
};
export function h(type, props = null, children?): VNode {
const key = props && "key" in props ? props.key : undefined;
const norm =
children == null
? null
: typeof children === "string"
? children
: Array.isArray(children)
? children
: [children];
return { type, props, children: norm, key, el: null, component: null };
}- VNode는 DOM(혹은 컴포넌트)을 설명하는 가벼운 객체.
key는 자식 재정렬 시 힌트로 활용(간단 구현이라면 부분 사용만 해도 OK).
2. createApp/render/patch의 라이프사이클
ts
export function render(vnode: VNode, container: Element) {
patch(null, vnode, container, null);
}
function patch(
n1: VNode | null,
n2: VNode,
container: Element,
anchor: Node | null
) {
if (typeof n2.type === "string") {
n1 ? patchElement(n1, n2, container) : mountElement(n2, container, anchor);
} else {
n1
? updateComponent(n1, n2, container, anchor)
: mountComponent(n2, container, anchor);
}
}- 최초 렌더:
n1이null→mount*경로. - 업데이트:
n1이 존재 →patch*경로. 엘리먼트냐 컴포넌트냐에 따라 분기.
컴포넌트는
effect(updateComponent)로 구동된다. 즉, 렌더도 effect다.
3. 엘리먼트 마운트: mountElement
ts
function mountElement(vnode: VNode, container: Element, anchor: Node | null) {
const el = document.createElement(vnode.type as string);
vnode.el = el;
// props 적용
const props = vnode.props;
if (props) for (const k in props) setProp(el, k, null, props[k]);
// children 적용
const c = vnode.children;
if (typeof c === "string") el.textContent = c;
else if (Array.isArray(c))
for (const child of c) patch(null, child, el, null);
container.insertBefore(el, anchor);
}- 첫 생성 시 속성/스타일/이벤트를 한 번에 세팅하고, 자식 트리를 재귀적으로 마운트한다.
4. 엘리먼트 패치: patchElement
ts
function patchElement(n1: VNode, n2: VNode, container: Element) {
const el = (n2.el = n1.el!);
patchProps(el, n1.props || {}, n2.props || {});
patchChildren(n1, n2, el);
}4.1 props diff: patchProps
ts
function patchProps(el: Element, oldProps: any, newProps: any) {
// 변경/추가
for (const k in newProps) setProp(el, k, oldProps[k], newProps[k]);
// 제거
for (const k in oldProps)
if (!(k in newProps)) setProp(el, k, oldProps[k], null);
}4.2 prop 적용 규칙: setProp
ts
function setProp(el: Element, key: string, prev: any, next: any) {
if (key === "class") {
(el as HTMLElement).className = next ?? "";
return;
}
if (key === "style") {
const style = (el as HTMLElement).style;
if (next) for (const n in next) style.setProperty(n, next[n]);
if (prev)
for (const p in prev) if (!next || !(p in next)) style.removeProperty(p);
return;
}
if (/^on[A-Z]/.test(key)) {
// 이벤트 인보커
const name = key.slice(2).toLowerCase();
let invoker = (el as any)._vei?.[name];
if (next) {
if (!invoker) {
invoker = (e: Event) => invoker.value && invoker.value(e);
invoker.value = next; // 핸들러 교체만
((el as any)._vei ||= {})[name] = invoker;
el.addEventListener(name, invoker);
} else {
invoker.value = next; // add/remove 없이 핫스왑
}
} else if (invoker) {
el.removeEventListener(name, invoker);
(el as any)._vei[name] = undefined;
}
return;
}
// DOM prop 직설 쓰기 케이스
if (key === "value" && "value" in (el as any)) {
(el as any).value = next ?? "";
return;
}
if (key === "checked" && "checked" in (el as any)) {
(el as any).checked = !!next;
return;
}
// 나머지: attribute
if (next == null || next === false) el.removeAttribute(key);
else el.setAttribute(key, String(next));
}- class: 문자열로 통째 교체(간단/빠름)
- style: key별 set/remove로 미세 diff
- event: 인보커(invoker)로 리스너 add/remove 최소화 + 핸들러만 교체
- DOM prop:
value,checked처럼 DOM 프로퍼티는 속성(Attribute) 대신 프로퍼티로 - 기타: attribute set/remove
인보커 패턴은 비용이 큰
removeEventListener / addEventListener를 줄이고, 핸들러 함수만 교체해서 빠른 업데이트를 보장한다.
5. 자식 패치: patchChildren
ts
function patchChildren(n1: VNode, n2: VNode, el: Element) {
const c1 = n1.children,
c2 = n2.children;
if (typeof c2 === "string") {
if (Array.isArray(c1)) el.textContent = c2; // 배열 → 텍스트
else if (c1 !== c2) el.textContent = c2; // 다른 텍스트 → 텍스트
return;
}
if (Array.isArray(c2)) {
if (typeof c1 === "string" || c1 == null) {
// 초기나 텍스트에서 배열로
el.textContent = "";
for (const child of c2) patch(null, child, el, null);
} else {
// 간단 순차 diff (같은 길이 앞부분 patch, 나머지 mount/unmount)
const len = Math.min(c1.length, c2.length);
for (let i = 0; i < len; i++)
patch(c1[i] as VNode, c2[i] as VNode, el, null);
if (c2.length > c1.length) {
for (let i = len; i < c2.length; i++)
patch(null, c2[i] as VNode, el, null);
} else if (c1.length > c2.length) {
for (let i = len; i < c1.length; i++) unmount(c1[i] as VNode);
}
}
return;
}
// 새 children이 null/undefined라면 전부 제거
if (Array.isArray(c1)) for (const child of c1) unmount(child as VNode);
else if (typeof c1 === "string") el.textContent = "";
}- 키 기반 고급 diff(LIS)는 생략한 단순 순차 비교. 학습용으로 충분하고, 실제 프로젝트에선 키 기반 최적화를 추가하면 된다.
ts
function unmount(vnode: VNode) {
const el = vnode.el as Node;
el.parentNode && el.parentNode.removeChild(el);
}6. 컴포넌트 마운트/업데이트
ts
function mountComponent(vnode: VNode, container: Element, anchor: Node | null) {
const comp = vnode.type as Component<any, any>;
const instance: ComponentInstance = {
vnode,
isMounted: false,
subTree: null,
el: null,
props: vnode.props || {},
state: {},
update: null,
};
// setup 결과(state) 준비
const setupResult = comp.setup?.(instance.props);
instance.state = (setupResult as any) ?? {};
const updateComponent = () => {
const ctx = proxyRefs({ ...instance.props, ...instance.state });
const nextTree = comp.render(ctx);
if (!instance.isMounted) {
patch(null, nextTree, container, anchor);
instance.isMounted = true;
} else {
patch(instance.subTree!, nextTree, container, anchor);
}
instance.subTree = nextTree;
vnode.el = instance.el = nextTree.el!;
};
// 핵심: 렌더는 effect로 구동 → 상태가 바뀌면 자동 재렌더
instance.update = effect(updateComponent);
vnode.component = instance;
}- 컨텍스트:
proxyRefs로 ref 자동 언래핑. 템플릿처럼ctx.count로 접근 가능. - 업데이트 경로: 상태/props 변경 →
trigger→instance.update재실행 →patch로 DOM 반영.
props 변경 시
updateComponent에서 새로운 props를 병합하거나, 부모 VNode 비교 과정에서shouldUpdateComponent같은 판정 함수를 둘 수 있다(간단 구현이면 생략 가능).
7. 텍스트/주요 노드 케이스
- 텍스트 노드: children이 문자열이면
textContent로 관리(간단/빠름). - 입력 요소:
value,checked등은 attribute가 아니라 DOM 프로퍼티로 다루어 사용자 입력과 동기화 문제를 피한다. - Fragment/Comment: 학습 단계에선 생략해도 무방. 필요 시 가상 루트와 앵커 노드를 두고 범위를 관리한다.
8. 실무 감각으로 보는 포인트
- 이벤트 인보커: 대량의 리스트에서 이벤트 핸들러가 자주 바뀌어도
add/remove를 반복하지 않으니 비용이 확 줄어든다. - 배치 업데이트: 렌더 effect는 보통 마이크로태스크에 모아 실행된다(엔진 쪽
queueJob). 상태를 여러 번 바꿔도 한 번만 재렌더. - 키 기반 diff: 실제 앱에선 reorder가 잦다. 여기엔 LIS(Longest Increasing Subsequence) 최적화가 필요하다. 학습 구현에선 순차 비교로 감만 잡고, 이후 확장 포인트로 삼자.
- 메모리 안전성: VNode의
el/component참조가 남아 있으면 GC가 못 가져간다. 언마운트 시 참조를 정리해주는 습관을 들여라.
요약
- 렌더러는 VNode를 DOM으로 투영하는 역할을 한다.
patch는 엘리먼트/컴포넌트를 분기해 마운트/업데이트 경로를 태운다.- props/style/event/children은 각자 규칙에 따라 최소 변경만 수행한다.
- 컴포넌트 렌더는 effect로 등록되어, 상태 변화가 곧 재렌더 트리거가 된다.
이제 반응성 엔진과 렌더러가 어떻게 맞물려 돌아가는지, 파일 단위로 전체 지도가 완성됐다. 다음 번엔 key 기반 children diff나 템플릿→render 변환(간단 컴파일러)을 추가해 미니 프레임워크를 한 단계 더 키워보자.